💡 今日學習目標:理解安全設定缺陷 (Security Misconfiguration) 為什麼在 2025 年衝上第二名,並實際動手把該加的 HTTP 安全標頭加上去、把該刪的刪掉,最後用工具驗收成果。
2021 年它排 A05,2025 年直接跳到 A02。
看一下 OWASP 官方的數字就知道為什麼:這個類別涵蓋 16 個 CWE,平均發生率 3.00%,但最高發生率高達 27.70%。換句話說,在某些檢測項目上,每四個受測應用程式就有一個中招。累計出現次數是 719,084 次。
而這一類最特別的地方在於:
它多半不是「寫錯程式」,而是「忘了設定」。
沒有人寫了一個有漏洞的函式,只是有人開了預設值就直接上線。Nginx、Apache、Express 的預設組態,設計目標是「相容性好、方便除錯」,從來就不是「安全」。
這也代表一件好事:這是 OWASP 十大裡面投入最少、拿分最快的一類。今天這篇你照著做,一個下午就能看到分數變化。
而我們今天要處理的主題,OWASP 在官方描述裡直接列成了一條判斷指標:
「The server does not send security headers or directives, or they are not set to secure values.」
(伺服器沒有送出安全標頭,或標頭的值設得不安全。)
大部分文章會給你一張「五大必備標頭」清單,然後你抄完就結束了。但那樣你只做了三分之二的工作,而且最重要的那一項多半做錯了。

真正該有的分類是這樣:
下面我們一項一項做。
動手改設定之前,先知道自己現在幾分。這樣改完才有對照。
打開 https://securityheaders.com,輸入網址,按下 Scan。
我們先掃 owasp.org,就是出版 OWASP Top 10 的那個組織:

六個標頭全綠,拿到 A。 但先別急著羨慕,畫面上有兩個地方值得停一下:
Warning: Grade capped at A — 這個 A 是被上限鎖住的。不是它拿不到 A+,是工具刻意不給。unsafe-inline 與 unsafe-eval。
連 OWASP 自己的網站都是這樣。 請先記住這個畫面,Step 3 我們會回來算這筆帳。
再看一個對照組。掃 ithelp.ithome.com.tw,也就是你現在正在讀這篇文章的地方:

C。 缺的三項是 Content-Security-Policy、Referrer-Policy 與 Permissions-Policy,而這三項今天全部都會處理到:後兩項 Step 2 就補得起來,CSP 則是 Step 3 的整個主題。
你可以自己多掃幾個常去的網站,你會發現拿 F 的比想像中多很多。
⚠️ 提醒一下:
securityheaders.com是第三方服務,你送進去的網址會被記錄,預設還會列進它公開的最近掃描清單。掃自己的正式站或公司內部系統時,務必勾選
Hide results(上面兩張截圖裡都勾了);掃公開網站則無所謂。另外,掃描別人的站之前,先確認你有權限這樣做。
這一組沒什麼好討論的,直接貼。
# 放在 server 區塊裡
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
🔑 那個
always不能省。少了它,Nginx 只會在 2xx / 3xx 的回應加上標頭,錯誤頁面(404、500)就通通沒有防護。而錯誤頁面剛好是最常被拿來做攻擊測試的地方。
const helmet = require("helmet");
app.use(helmet()); // 一行就把上面這些預設值都設好了
app.disable("x-powered-by"); // 順手把 Express 的身分證拿掉
各標頭在做什麼:
| 標頭 | 作用 | 沒設會怎樣 |
|---|---|---|
Strict-Transport-Security |
告訴瀏覽器「這個網域以後只准走 HTTPS」 | 使用者在咖啡廳被降級成 HTTP 攔截 |
X-Content-Type-Options: nosniff |
禁止瀏覽器「猜」檔案型別 | 上傳的圖片被當成 JS 執行 |
Referrer-Policy |
控制跳轉到外站時帶出多少來源資訊 | 完整網址(含參數)洩漏給第三方 |
X-Frame-Options: DENY |
禁止被 <iframe> 嵌入 |
點擊劫持 (Clickjacking) |
Permissions-Policy |
關掉用不到的瀏覽器功能 | 被注入的腳本有機會存取相機/定位 |
💡 關於
X-Frame-Options,補一個容易搞混的點:它沒有被廢棄,DENY和SAMEORIGIN都還能正常運作(只有ALLOW-FROM是過時的,現代瀏覽器會直接忽略整個標頭)。但 CSP 的frame-ancestors功能更完整,而且兩者同時存在時frame-ancestors優先。實務建議:兩個都設。新瀏覽器吃
frame-ancestors,老瀏覽器吃X-Frame-Options。
HSTS 是這組裡唯一有機會把你自己鎖在門外的標頭,所以單獨拉出來講:
max-age,就能對你做 DoS。includeSubDomains 會波及所有子網域。如果你有內部系統還跑在 http://admin.example.com,加上去的當下它就連不上了。先確認每一個子網域都上了 HTTPS 再加。
preload 請最後再說。加入 preload 清單等於把你的網域寫進瀏覽器的原始碼裡,移除要等好幾個版本、數個月起跳。先用 max-age=300 跑幾天確認沒事,再慢慢拉長到一年、兩年,最後才考慮 preload。Google 的研究團隊掃描了 168 萬個網站上的 CSP 設定,結論是:
在所有嘗試限制腳本執行的 CSP 政策中,94.68% 是可以被繞過的。
— Weichselbaum et al., CSP Is Dead, Long Live CSP!, ACM CCS 2016
而那 94.68% 裡,絕大多數用的就是你在幾乎每一篇教學文章上看到的那種寫法:
❌ Content-Security-Policy: script-src 'self' https://cdn.example.com;
問題出在哪?你信任了一整個網域。而那個 CDN 上只要有任何一支可以被利用的檔案(一個舊版 AngularJS、一個 JSONP 端點),攻擊者就能借道它把腳本送進來。研究中最常被列入白名單的 15 個網域,有 14 個含有這種不安全的端點。
你以為你設了一道牆,其實你開了一扇後門。
🔙 回頭看 Step 1 那張
owasp.org的截圖。 把它實際的script-src撈出來,長這樣:
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com https://unpkg.com ⋯(後面還有十幾個網域)沒有 nonce、沒有
strict-dynamic,只有一長串網域白名單,外加unsafe-inline(直接放行行內腳本)與unsafe-eval(允許把字串當程式碼執行)。這正是上面那篇論文歸類為「可繞過」的教科書寫法:
cdnjs與unpkg上面躺著數以萬計的函式庫版本,要從裡面翻出一支可以當跳板的舊檔案,並不困難。所以 securityheaders 才把等級鎖在 A:標頭有設,但擋不住它本來該擋的東西。
這也是為什麼那個 94.68% 不是在說「大家都不裝 CSP」,而是在說:大部分裝了的人,裝的是一個過不了的濾網。
要送出的回應標頭長這樣:
Content-Security-Policy: script-src 'nonce-{每次請求都不同的隨機值}' 'strict-dynamic' 'unsafe-inline' https:; object-src 'none'; base-uri 'none'
⚠️ 這一行不能折行。 很多文章為了排版好看,會把 CSP 拆成好幾行縮排呈現,但 HTTP 標頭的折行寫法(obsolete line folding)在 RFC 7230 已經被廢棄,現代伺服器與代理會直接拒絕。它看起來很長,但必須是單一一行。
那個 nonce 從哪裡來?它必須由應用程式在每一個請求產生,因為同一組值要同時出現在標頭與 HTML 裡:
const crypto = require("node:crypto");
app.use((req, res, next) => {
// 每個請求產生一組,稍後樣板也要用同一組
res.locals.nonce = crypto.randomBytes(16).toString("base64");
res.setHeader("Content-Security-Policy",
`script-src 'nonce-${res.locals.nonce}' 'strict-dynamic' 'unsafe-inline' https:; ` +
`object-src 'none'; base-uri 'none'`);
next();
});
搭配 HTML({同一組隨機值} 就是上面那個 res.locals.nonce):
<script nonce="{同一組隨機值}">
// 只有帶著正確 nonce 的腳本才會被執行
</script>
三個關鍵:
nonce:每次請求都重新產生。攻擊者注入的腳本猜不到這次的 nonce,就執行不了。這跟白名單最大的差別是,它信任的不是「來源」,而是「這段腳本確實是我這次產生的」。strict-dynamic:讓已經通過驗證的腳本可以自己再載入其他腳本(不然一堆前端框架會壞掉)。它還有一個關鍵的副作用:CSP3 瀏覽器只要看到 strict-dynamic,就會直接忽略 'unsafe-inline' 與後面那串 https: 白名單。
🤔 等一下,剛剛不是才罵 OWASP 有
unsafe-inline,怎麼我們自己也寫?差別就在
strict-dynamic。我們這行政策裡的'unsafe-inline'與https:是寫給不支援 CSP3 的舊瀏覽器看的退路,新瀏覽器會直接無視它們、只認 nonce。而 owasp.org 的政策沒有strict-dynamic、也沒有 nonce,所以它的unsafe-inline是真的生效的。同一個關鍵字,在有沒有
strict-dynamic的政策裡,意義完全相反。 這也是 CSP 難寫的原因:它的指令彼此會互相改寫語意,不能一條一條分開讀。
base-uri 'none':這一項超級容易漏掉。如果攻擊者能注入一個 <base href="https://evil.com">,你頁面上所有相對路徑的 script 就全部改從他家載入了,CSP 的其他設定完全擋不住這招。🔑 重點在「同一組」這三個字。 標頭裡的 nonce 與 HTML 裡的 nonce 必須逐字元相同,而且每個請求都要換一組新的。
這帶出一個很容易踩的坑:如果你的頁面有做整頁快取(CDN、反向代理、靜態產生),nonce 就會被凍結成同一組,攻擊者只要讀一次原始碼就知道下次要填什麼,整套防護等於歸零。用 nonce 的頁面不能整頁快取,這件事沒有折衷方案。
CSP 是唯一有機會把你自己的網站弄壞的標頭。所以絕對不要一次就上正式版:
第一步先送這個標頭,只回報、不阻擋,讓它跑幾天:
Content-Security-Policy-Report-Only: script-src 'nonce-xxx' 'strict-dynamic'; report-uri /csp-report
用 Report-Only 版本,違規行為只會記錄下來、不會被擋。等你看完報告、把自己網站上的問題都清乾淨了,再把標頭名稱改成正式的 Content-Security-Policy。
這一步最容易被忽略,但它成本幾乎是零。
# Nginx:關掉版本號與目錄瀏覽
server_tokens off;
autoindex off;
// Express
app.disable("x-powered-by");
<!-- ASP.NET:web.config -->
<httpRuntime enableVersionHeader="false" />
還有一個特別的,X-XSS-Protection 請明確關掉:
X-XSS-Protection: 0
沒錯,是設成 0,不是移除、更不是設成 1。這個標頭當年是用來啟動瀏覽器內建的 XSS 過濾器,但後來發現那個過濾器本身會在原本安全的網站上製造出漏洞,各家瀏覽器已陸續移除它。OWASP 現在的建議就是明確設成 0 停用。
🔑 這是一個很好的提醒:資安建議是有保存期限的。你在網路上查到的「必備標頭清單」,很可能是五年前寫的。而五年前的正確答案,今天可能剛好是錯的。
改完之後,不用等瀏覽器,一行指令就能確認:
curl -I https://你的網域
-I 只抓回應標頭。對照一下上面幾個 Step,該有的有沒有出現、該消失的 Server 版本號有沒有不見。
然後回到 Step 1 的 securityheaders.com 再掃一次,看分數有沒有跳上去。
strict-dynamic,要嘛就承認你只是在應付掃描器。💬 明日預告:【Day 20】【動手做】OWASP A07 認證失效:Session Cookie 與 JWT 防禦
今天我們把門窗鎖好了,明天要處理的是鑰匙本身。當使用者登入後拿到的那張憑證被偷走或被偽造,前面所有防線都會一起失效。